spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars, Donut, HBars, StackedBars } from '@/components/charts/charts';5import { Block, EventsList, HeroFacts, KpiStrip, PlannedUnavailable, ScrollTable, Tag, entityMetadata } from '@/components/entities/shared';6import { ConstellationsMiniTable, LaunchesTable, OperatorsMiniTable, SatellitesTable } from '@/components/entities/tables';7import { WorldMap } from '@/components/map/world-map';8import { Container } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { ApiError, api, safe } from '@/lib/api';11import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';12import { MISSION_LABELS, OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, SITE_URL, routes } from '@/lib/site';13import type { CountryDetail } from '@/lib/types';1415type Props = { params: Promise<{ slug: string }> };1617async function load(slug: string): Promise<{ d: CountryDetail; generatedAt: string } | null> {18 try {19 const res = await api.country(slug);20 return { d: res.data, generatedAt: res.meta.generated_at };21 } catch (e) {22 if (e instanceof ApiError && e.notFound) return null;23 throw e;24 }25}2627export async function generateMetadata({ params }: Props): Promise<Metadata> {28 const { slug } = await params;29 const r = await load(slug).catch(() => null);30 if (!r) return { title: 'Country not found', robots: { index: false } };31 const { d } = r;32 return entityMetadata({33 title: `${d.name} in orbit — ${fmtInt(d.active_payloads)} active satellites, ${fmtInt(d.objects_on_orbit)} objects`,34 description: `${d.name}: ${fmtInt(d.active_payloads)} active payloads, ${fmtInt(d.objects_on_orbit)} objects on orbit including ${fmtInt(d.debris_on_orbit)} debris fragments, ${fmtInt(d.launches)} launches and ${fmtInt(d.operators)} operators. Rankings, orbital and mission distribution, growth, launch sites and recent launches.`,35 path: routes.country(d.slug),36 });37}3839function Rank({ n, of }: { n: unknown; of: number | null }) {40 const v = num(n as never);41 if (v === null) return <>—</>;42 return (43 <>44 #{v}45 {of !== null && <span className="text-ink-3"> of {of}</span>}46 </>47 );48}4950export default async function CountryPage({ params }: Props) {51 const { slug } = await params;52 const [r, all] = await Promise.all([load(slug), safe(api.countries())]);53 if (!r) notFound();54 const { d, generatedAt } = r;55 const nCountries = all?.data.length ?? null;5657 const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);58 const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);59 const payloadGrowth = d.growth.map((g) => {60 const p = num(g.payloads) ?? 0;61 const still = Math.min(p, num(g.still_active) ?? 0);62 return { x: g.year, still_active: still, retired: Math.max(0, p - still) };63 });64 const launchGrowth = d.growth.map((g) => ({ x: g.year, y: num(g.launches) ?? 0 }));65 const sites = d.launch_sites.filter((s) => s.latitude !== null && s.longitude !== null);66 const objectTypes = [...d.object_type_distribution].sort((a, b) => (num(b.on_orbit) ?? 0) - (num(a.on_orbit) ?? 0));6768 const jsonLd = {69 '@context': 'https://schema.org',70 '@type': 'Country',71 name: d.name,72 identifier: d.iso3 ?? d.code,73 url: `${SITE_URL}${routes.country(d.slug)}`,74 };7576 return (77 <Container wide>78 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />7980 <header className="pb-6 pt-8 md:pb-8 md:pt-12">81 <nav aria-label="Breadcrumb" className="eyebrow">82 <Link href={routes.countries()} className="hover:text-ink">Countries</Link> <span aria-hidden>/</span> {d.name}83 </nav>84 <div className="mt-3 flex flex-wrap items-center gap-2">85 <Tag tone="accent">{d.code}</Tag>86 {d.iso3 && <Tag>{d.iso3}</Tag>}87 {d.region && <Tag>{d.region}</Tag>}88 </div>89 <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>90 <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">91 {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.objects_on_orbit)} objects on orbit · {fmtInt(d.operators)} operators92 </p>93 <HeroFacts94 items={[95 { label: 'Rank · active payloads', value: <span className="tnum font-medium"><Rank n={d.rank_active} of={nCountries} /></span> },96 { label: 'Rank · objects on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_objects} of={nCountries} /></span> },97 { label: 'Rank · debris on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_debris} of={nCountries} /></span> },98 ]}99 />100 {nCountries === null && <p className="mt-2 text-xs text-ink-3">Country total unavailable — ranks shown without denominator.</p>}101 </header>102103 <KpiStrip104 items={[105 { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },106 { label: 'Payloads on orbit', value: fmtInt(d.on_orbit_payloads) },107 { label: 'Total payloads', value: fmtInt(d.total_payloads) },108 { label: 'Objects on orbit', value: fmtInt(d.objects_on_orbit) },109 { label: 'Debris on orbit', value: <span className="text-warn">{fmtInt(d.debris_on_orbit)}</span> },110 { label: 'Rocket bodies', value: fmtInt(d.rocket_bodies_on_orbit) },111 { label: 'Total objects', value: fmtInt(d.total_objects) },112 { label: 'Launches', value: fmtInt(d.launches) },113 { label: 'Operators', value: fmtInt(d.operators) },114 { label: 'Payloads 365 d', value: fmtInt(d.payloads_last_365d) },115 { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },116 ]}117 />118119 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">120 <div className="min-w-0">121 <Block eyebrow="Growth" title="Growth by year" id="growth">122 {d.growth.length ? (123 <div className="grid gap-6 lg:grid-cols-2">124 <div>125 <p className="eyebrow mb-2">Payloads launched · still active vs no longer active</p>126 <StackedBars data={payloadGrowth} keys={['still_active', 'retired']} labels={{ still_active: 'Still active', retired: 'No longer active' }} title="Payloads by launch year" height={190} />127 </div>128 <div>129 <p className="eyebrow mb-2">Launches per year</p>130 <Bars data={launchGrowth} title="Launches per year" height={190} color="var(--series-4)" highlightLast />131 </div>132 </div>133 ) : (134 <Unavailable what="Growth history" />135 )}136 </Block>137 <Block eyebrow="Organisations" title={`Operators · ${fmtInt(d.operators)}`} id="operators" action={{ href: `${routes.operators()}?country=${encodeURIComponent(d.slug)}`, label: 'All operators' }}>138 <OperatorsMiniTable rows={d.operators_list} />139 </Block>140 <Block eyebrow="Programmes" title="Constellations" id="constellations">141 <ConstellationsMiniTable rows={d.constellations_list} />142 </Block>143 <Block eyebrow="Ground" title="Launch sites" id="launch-sites" action={{ href: routes.launchSites(), label: 'All launch sites' }}>144 {d.launch_sites.length === 0 ? (145 <p className="text-sm text-ink-3">No launch site on this country's territory in the catalogue; its payloads fly from foreign sites (see recent launches).</p>146 ) : (147 <div className="space-y-4">148 {sites.length > 0 && <WorldMap title={`Launch sites in ${d.name}`} markers={sites.map((s) => ({ lat: s.latitude!, lon: s.longitude!, label: s.code, href: routes.launchSite(s.slug), size: 4 + Math.min(6, Math.log10((num(s.launches) ?? 1) + 1) * 2) }))} />}149 <ScrollTable>150 <table className="data-table stack">151 <thead>152 <tr>153 <th>Site</th>154 <th>Code</th>155 <th className="num">Launches</th>156 <th>Last launch</th>157 </tr>158 </thead>159 <tbody>160 {d.launch_sites.map((s) => (161 <tr key={s.code}>162 <td className="primary" data-label="Site"><Link href={routes.launchSite(s.slug)} className="link">{s.name}</Link></td>163 <td data-label="Code" className="mono text-xs text-ink-2">{s.code}</td>164 <td data-label="Launches" className="num tnum">{fmtInt(s.launches)}</td>165 <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(s.last_launch)}</td>166 </tr>167 ))}168 </tbody>169 </table>170 </ScrollTable>171 </div>172 )}173 </Block>174 <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`country=${encodeURIComponent(d.slug)}`), label: 'All satellites' }}>175 <SatellitesTable rows={d.recent_satellites} columns={['type', 'orbit']} />176 </Block>177 <Block eyebrow="Launches" title="Recent launches" id="launches" action={{ href: routes.launches(`country=${encodeURIComponent(d.slug)}`), label: 'All launches' }}>178 <LaunchesTable rows={d.recent_launches} showPrimary />179 </Block>180 <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(), label: 'All events' }}>181 <EventsList events={d.events} />182 </Block>183 </div>184185 <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">186 <Block eyebrow="Payloads on orbit" title={<span className="inline-flex items-center gap-2">Orbital distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>187 <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />188 </Block>189 <Block eyebrow="Payloads" title={<span className="inline-flex items-center gap-2">Mission distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>190 <HBars data={missionData} />191 </Block>192 <Block eyebrow="Catalogue" title="Object types">193 {objectTypes.length === 0 ? (194 <Unavailable what="Object type breakdown" compact />195 ) : (196 <ScrollTable>197 <table className="data-table">198 <thead>199 <tr>200 <th>Type</th>201 <th className="num">On orbit</th>202 <th className="num">Total</th>203 </tr>204 </thead>205 <tbody>206 {objectTypes.map((o) => (207 <tr key={o.object_type}>208 <td>{OBJECT_TYPE_LABELS[o.object_type] ?? titleCase(o.object_type)}</td>209 <td className="num tnum font-medium">{fmtInt(o.on_orbit)}</td>210 <td className="num tnum text-ink-2">{fmtInt(o.total)}</td>211 </tr>212 ))}213 </tbody>214 </table>215 </ScrollTable>216 )}217 </Block>218 <Block eyebrow="Attribution" title="Owner codes">219 {d.owner_codes.length === 0 ? (220 <Unavailable what="Owner code mapping" compact />221 ) : (222 <ul className="divide-y divide-rule text-sm">223 {d.owner_codes.map((o) => (224 <li key={o.code} className="flex items-center justify-between gap-3 py-2">225 <span className="min-w-0 truncate text-ink-2">{o.name}</span>226 <span className="flex shrink-0 items-center gap-2">227 <Tag>{o.kind}</Tag>228 <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{o.code}</code>229 </span>230 </li>231 ))}232 </ul>233 )}234 <p className="mt-3 text-xs text-ink-3">235 SATCAT owner codes mapped to this country. Joint programmes are attributed to the lead country; the mapping is versioned in the <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.236 </p>237 </Block>238 <Block eyebrow="Regulatory" title="Registrations & licenses">239 <PlannedUnavailable what="Registrations & licenses" note="UNOOSA register and national regulatory connectors are planned; entries will carry their source once ingested." />240 </Block>241 </aside>242 </div>243 </Container>244 );245}246